搜索:Ctrl+K 支持会话聊天内容搜索,并支持命中定位与高亮 - #488
Conversation
- Persist an empty tombstone document for conversations whose transcript parses to zero turns (e.g. cancelled Claude sessions with no jsonl file) so the progress denominator counts them as handled and drift resync stops re-queueing them every ten minutes. - Fix search hit navigation to scan merged turn wrappers in reverse and scroll the highlighted mark itself to viewport center with a settle pass.
- Window the detail response only AFTER the full turn list has been handed to the indexer. Previously the tail-windowed turns were submitted, so opening a conversation could overwrite its indexed document with the last 120 turns and silently drop older history. - Stop submitting paged older-history slices to the indexer entirely. - Skip re-normalizing unchanged transcripts via the same source-metadata dirtiness probe drift_resync uses, keeping repeated opens cheap. - Drop match_kind/content_match_count/total_match_count, which no consumer reads, and the unused store setter.
|
先说结论:这个 PR 的工作量和完成度都很可观 —— 设计文档带实测数据、双运行模式共用核心切得干净、10 种语言文案一个不落(我逐个 locale 核对过 10/10), 不过我把后端和前端通读了一遍,并对几处存疑的地方做了实测复现,发现有 3 个问题会让功能在特定条件下直接不可用,还有若干条建议在合并前处理。下面按优先级列一下,附上复现证据,供你参考。 🔴 P0-1:FTS 模式下 1–2 字符查询直接报错(已复现)
ShortTermQuery::CjkUnigram { token } => format!("words : \"{token}\""),
ShortTermQuery::CjkBigram { phrase } => format!("bigrams : \"{phrase}\""),
ShortTermQuery::LatinPrefix{ token } => format!("words : \"{token}\"*"),SQLite FTS5 不支持 裸 sqlite3 3.51 同样复现,换 触发条件:设置页选「全文索引」,或 auto 模式下可索引文本 ≥ 40MB —— 影响:整个 修法:把 🔴 P0-2:限定文件夹 + FTS 模式 → 内容命中被静默丢光(已复现)
SELECT d.conversation_id, bm25(t) AS rank FROM t
JOIN message_search_document d ON d.id = t.rowid
WHERE t MATCH ? AND d.text LIKE ? ESCAPE '\' ORDER BY rank LIMIT ?LIMIT 是在全库文档上截断的,可见性过滤发生在之后的 Rust 侧。目标文件夹只有 3 个会话、全库有 5000 个匹配文档时,取到的是全局前 3 条,几乎必然不含目标文件夹。 复现(30 个噪声会话在别的文件夹,1 个匹配会话在目标文件夹,限定目标文件夹搜索): 设计文档 §9.2 的论证是「索引一行对应一个会话,因此任何单个词最多只能命中可见会话数;该默认值保证不会静默漏召回」—— 这个前提只有在 SQL 里带上可见性过滤时才成立,所以恰好落在了本 PR 主打的「限定项目范围」上。多词查询也受害(各词各自截断到 N 条再求交集,交集可能为空)。 修法: 🔴 P0-3:当前 contentless 布局下
|
The arena sends one task to 2-4 agents simultaneously, gives each its own git worktree under `<folder>/.codeg-pk/<round>/<agent>/`, and renders the contest live: a scoreboard (status / duration / output tokens / turns) over one LiveTranscriptView column per contestant, a diff tab that fetches each worktree's unified diff once the round settles, and a share button that exports the scoreboard as a PNG via html-to-image. Pure frontend — no backend changes: the orchestrator drives the existing connection machinery directly (connect/sendPrompt on synthetic `pk:` context keys, the pattern delegation children already use), completion is detected from per-connection status_changed events, and token/turn stats are summed from the persisted conversation. This deliberately bypasses the delegation broker: its v1 metrics are placeholders (turn_count: 1, token_usage: None at lifecycle.rs:342) and it cannot run parentless, so an arena built on it would need new backend surface for no gain. Distinct worktrees double as contestant isolation AND unlock same-agent rematches (connections dedup by agent+cwd, so two Claude slots need two cwds). Rounds persist to localStorage; a round still running at shutdown revives as `interrupted` with settled contestants kept and live fields dropped. All ten locales carry the new keys (the parity test enforces it).
A round's first step is git worktree add, which fails instantly in a plain folder — the launcher now probes getGitBranch(workingDir) before enabling Start, explains why, and offers a one-click git init (the backend command already existed) instead of letting every contestant die at the worktree step with a raw git error.
…omplete Two live-round bugs, one root each: 1. Empty battle tab until a diff-tab round trip. LiveTranscriptView resolves its connection with useConnectionStateById, which looks the store up BY connectionId — an entry shape only delegation children have (attach registers contextKey == connectionId). The arena's connections live under their pk: contextKeys, so the bridge saw no connection state: no live mirror, no mount-time promotion. Attach each contestant as a delegation child right after connect (before the first prompt, so the whole turn flows through the by-id entry), and detach on cancel/cleanup. 2. Finished contestants stuck on running. The backend settles the turn at TurnComplete WITHOUT emitting a status_changed envelope (session_state.rs: "bypassing StatusChanged entirely"), so the prompting→settled edge the scoreboard waited for never arrives. turn_complete is the settle signal now; status_changed keeps only the prompting edge.
A freshly git-inited folder (the launcher's own one-click init leaves exactly this state) has no commits, and 'git worktree add -b <branch>' fails against it with 'invalid reference: HEAD' — every contestant died at the worktree step with a raw git error. git_worktree_add now detects the unborn HEAD and seeds an empty initial commit first, with inline identity overrides so machines without global user.name/user.email work too. Only reachable in a state that errored outright before, so every existing caller sees strictly fewer failures. Regression-tested.
A 4-agent round can rack up a dozen approval interruptions (one observed round: Codex waited ~3 extra minutes on four manual approvals). The launcher now carries a permission policy — ask every time / auto-accept edits / full auto — stored on the round and applied to every contestant right after connect via session/set_mode, but only when the agent actually advertises the requested mode id (the ACP-standard spellings: default/acceptEdits/bypassPermissions); anything else keeps its own flow rather than failing the connect sequence. Modes arrive shortly after connect resolves, so the orchestrator polls the connection store for the advertised set (200ms steps, 5s cap) before switching.
Field report from a 4-agent round, three symptoms, three fixes: 1. Arena laggy over time. The 1s scoreboard clock lived in the DIALOG, re-rendering four streaming transcript panes every second — the tick now lives inside the scoreboard, so only the small cards re-render. Battle panes are additionally memoized on stable props: the dialog re-renders on every contestant store update (any status/usage/diff), and unmemoized panes took four markdown trees along each time. 2. No way back after the dialog closes (and a laggy dialog is easy to ESC). The composer menu item now reopens the ARENA directly when a round exists, and the arena header gains a 'new round' button that opens the launcher — the loop is closed both ways. 3. 'thread already has an active writer' on session resume and the deepseek-acp empty-callId validation failure both surfaced when reopening contestant conversations from the sidebar while the round connections were still alive — upstream bridge issues (tracked as deepseek-acp#2), codeg's fallback handled them; nothing to fix here.
The mode presets never fired: modes arrive as a session_modes EVENT after session/new, but the arena attaches each contestant as a by-id delegation child right after connect — the attach re-routes the reverseMap to the by-id entry, so the event lands there and the owner (contextKey) entry stays modes=null. The 5s poll on the owner entry timed out and the mode was silently skipped, leaving every agent on its ask-every-time default (field report: presets 'did not apply'). Poll both entries (pre-attach events land on the owner, post-attach on the by-id one), extend the timeout to 8s.
Claude Code and Codex pull 22 and 25 global skills respectively on this machine; a PK round was silently played with skill-loaded contestants (and project-level skills only vanish because worktrees are fresh). A startup flag cannot do it either: the ACP adapters run the agent SDK and codeg's launch args only reach the adapter, not the inner runtime. Bare mode adds a fair-play instruction block to the task prompt: no skills, slash commands, plugins, custom agents, or instructions from the global skill stores (~/.claude/skills, ~/.codex/skills, ~/.agents/skills) or repo skill dirs. Soft constraint by nature — the model still sees the global skill content — but applied uniformly to every contestant, so comparisons stay apples-to-apples. Toggle lives in the launcher next to the permission presets and persists on the round.
…kers A PK round was locked to each agent's defaults: the launcher's permission presets applied, but model and reasoning effort could not be touched, and a uniform effort is a fairness lever (defaults differ wildly per agent — claude low/medium/high vs codex minimal..max vs deepseek off..high). Rounds now go through a READY phase: contestants connect, the arena applies the round's uniform effort request (nearest advertised level per agent, canonical rank off<minimal<low<medium<high<max, ties resolve higher), then the arena shows per-contestant model + effort pickers (from the advertised configOptions) and a 'Start match' button — the prompt fires only after the user confirms. Per-pane overrides go straight to the backend via setConfigOption. configOptions arrive as events routed to the by-id entry after attach (the same re-routing the mode fix hit), so the polling reads whichever entry holds them. Rounds left in ready state at shutdown revive as interrupted, same as running.
The unified option poll resolved as soon as modes OR configOptions arrived. applyPermissionMode ran first and consumed the modes; by the time applyPreparedOptions polled, modes were already set, so the poll resolved on the first tick with configOptions still null — model and effort pickers never rendered (field report: 'no model selection anywhere on the PK page'). waitForField now blocks on the exact field each caller needs, timeout raised to 10s.
The dual-entry poll used `owner ?? byId` — entry precedence, not field precedence. The owner entry always exists (connect creates it) but never receives configOptions/modes for arena connections (snapshot hydrate skips or lands elsewhere; the events route to the by-id entry post- attach), so `owner ?? byId` pinned the poll to a field-less entry, timed out, and silently dropped BOTH selector data and the permission mode — field report: no model pickers on any contestant, owner=none byId=N for all four. Check the field on each entry and take whichever has it; one root bug behind both the selector gap and the 'presets did not apply' report.
… when the round settles Two field reports: 1. The arena dialog closed on a stray ESC or outside click mid-round, taking the live view with it. A live round (ready/running) now blocks both ESC and pointer-outside dismissal — only the explicit X close works — so the arena cannot vanish under the user. 2. After a round finished, the contestant conversations kept spinning in the sidebar because their connections stayed alive until the idle sweep. On settle (all done/error/canceled) the round now disconnects every contestant and detaches the by-id entries immediately: results stay in the persisted transcripts, the panes render them over the live stream (connectionId null), and the sidebar goes quiet. Opening any contestant conversation as a normal tab still reconnects.
…entry Three field reports: 1. Empty diff tab. fetchDiff ran `git diff` inside the contestant worktree — working tree vs ITS OWN branch, which is empty once the contestant commits. Now it diffs against the round repo's current branch (`git diffWithBranch <base>`), capturing both committed and uncommitted work; falls back to a plain worktree diff when no base branch resolves. 2. Header stuck at 'ready' after the round settled. If the finish transition was missed (a settle event dropped, a reopen after restart), the ready banner never flipped. The arena now self-heals: opening a round whose contestants are all settled bumps it to finished and disconnects any residual connections. 3. The PK entry hid behind the composer '+' and was undiscoverable. A ⚔ button now lives in the always-visible top-left window chrome (LeftEdgeChrome, next to the sidebar toggle): opens the arena when a round exists, else the launcher.
Two field asks: 1. The arena was a blocking modal — closing it felt like losing the round, and there was no way to work elsewhere while a match ran. A live round now minimizes into a small pill pinned bottom-right (⚔ done/total + live pulse), kept while the header's new 'Minimize' button collapses the dialog; the match keeps running in the background and the pill restores the full view on click, or hides on its X (resummon via the top-left ⚔ chrome button; a new round resets it). The round is never lost — local rounds persist and reopen. 2. Why only 4 contestants? The cap was cosmetic (four 1fr columns stop being readable). Raised to 8 with battle and scoreboard grids switched to fixed-min-width horizontal scroll, so 6-8 agents are still legible.
One-page report of a round, generated purely on the frontend and saved as a single self-contained .html (no external deps, opens in any browser) — for sharing results with colleagues or posting, and as an archive. Header carries the round settings (permission / bare mode / effort / duration); scoreboard table adds per-contestant diff +/- and output-file counts (file tree flattened from the worktree via get_file_tree); each contestant expands to its output files and the branch-base diff with red/green/hunk coloring (the SAME diff the diff tab shows). Diffs are fetched on demand if the tab was never opened.
…, drop the dialog close icon
Field report — a DeepSeek contestant never finished: its turn ended
{kind: interrupted} despite 4/4 clean tool calls, and the round stayed
stuck. Root cause: the backend idle sweep reaps connections whose
activity is never bumped; arena pk: keys are not registered as open tabs,
so nothing kept a slow (thinking-heavy) turn alive past the ~3min idle
timeout. The arena now touches each live contestant's activity every 20s,
mirroring what visible tabs do, and a disconnected-during-turn contestant
settles as a failure instead of hanging at 'running'. Also removed the
in-dialog close icon per request (left-chrome ⚔ / minimize / ESC for
non-live rounds cover closing); a side effect is that honest rounds now
finish, which also unblocks the export report's completeness.
… errors Console report during a PK round: '<p> cannot be a descendant of <p>' fired twice from ReasoningContent. DeepSeek's reasoning streams mix raw HTML/SVG into the markdown, and Streamdown's default <p> wrapper plus a nested element rendered as <p> trips React's nested-paragraph invariant. Override the paragraph renderer in the reasoning block to <div> (keeps spacing and semantics for the meta block; makes any nested content legal). Fixes the report at the shared component, so every agent's reasoning benefits, not just the arena path.
… up; tile the ready view Two field reports: 1. Picking a not-installed agent (Pi) blocked at connect preflight, but the other contestants' conversations were already created and idled spinning. The picker now only lists agents that are installed (installed_version present — PK can't download on demand), and Start preflights every selected agent (acpGetAgentStatus) with a named error before any round is created. A side path closed too: closing the arena while a round is still in READY (never started) now cancels that round and disconnects its contestants instead of leaving orphaned sessions spinning, and ESC is allowed in the ready state precisely for this. 2. The 4-contestant ready view rendered as a sparse single column of small cards. The ready panes are now full-column cards in the same tiled grid as the battle view (agent header + model/effort pickers), so entering a 4-agent round immediately reads as a proper 4-way spread.
# Conflicts: # src-tauri/src/db/migration/mod.rs # src-tauri/src/lib.rs # src/i18n/messages/ar.json # src/i18n/messages/de.json # src/i18n/messages/en.json # src/i18n/messages/es.json # src/i18n/messages/fr.json # src/i18n/messages/ja.json # src/i18n/messages/ko.json # src/i18n/messages/pt.json # src/i18n/messages/zh-CN.json # src/i18n/messages/zh-TW.json # src/lib/api.ts
…t-search # Conflicts: # src-tauri/src/db/migration/mod.rs # src/components/conversations/conversation-detail-panel.tsx # src/components/message/message-list-view.tsx
# Conflicts: # src/lib/sidebar-view-mode-storage.ts
- 战报文案从 buildPkReportHtml 抽离到 pk-report-locales.ts,报告页支持阿拉伯语 RTL - 归档对局时报告快照清理改为 best-effort,失败仅告警不阻断归档 - 竞技场 store 增加持久化失败提示与重试保存入口
The clone dialog built its preview as `${targetDir}/${repoName}`, so a
Windows target rendered `C:\work/codeg` — a native prefix with a stray
forward slash bolted on. Seven more sites shared that shape or its
mirror image (a `/`-only split that never finds a segment boundary in a
backslash path):
- clone dialog: the preview and the path actually cloned into
- project-boot "project will be created at" hints (shadcn, hyperframes)
- skills settings "skills directory" draft hint
- branch dropdown's prefilled worktree path, which came out as the bare
relative "/C:\work\repo-main-abc123" because `lastIndexOf("/")` is -1
- tool-call titles, which showed a whole absolute path instead of its
last two segments
- the working-diff overview tab, titled after the folder path
- automation's per-run worktree, whose sibling path degraded to a
relative name git would have planted inside the repo
Route the joins through the existing `joinFsPath`, which follows the
base path's own separator, and add `fsSeparator` / `fsBaseName` /
`siblingFsPath` beside it for the basename and sibling cases.
Roots need their own handling: `C:\`, `/`, and `\\server\share` have no
parent to hang a sibling off, so the derived name lands inside the root
instead, keeping the result absolute. A relative one would make git
resolve a worktree inside the repository and then register that
unresolved string as the folder's working directory. `basename` reports
no name at a root rather than feeding a colon into a directory name.
# Conflicts: # src/contexts/tab-context.tsx # src/stores/tab-store.ts
Delivering a forge-sourced task — opening its pull request, or pushing back onto the one it came from — was the only acceptance that could not take its checkout with it, leaving a worktree the user had to find on the card and remove by hand. Both shapes of the delivery dialog now offer the same checkbox the merge and complete dialogs do, seeded from the folder's `delete_worktree_default`. The cleanup rides on the delivery rather than gating it: it runs only after the settle, and a removal that fails flags a retryable `cleanup_state` rather than turning a pull request that was already pushed into a reported failure. Two probes decide whether the checkout may go, because each is blind to exactly what the other sees: uncommitted files, which reached no forge, and a branch tip that outran the OID the delivery published — a commit made in that window leaves `git status` spotless and is just as unpublished. `has_landable_changes` cannot serve as the second probe here, being true of every delivery by construction. The tip then rides into the removal itself, so `update-ref -d <ref> <oid>` compares and deletes in one operation instead of leaving a window between the check and `branch -D`; every local-merge caller passes `None` and is unchanged.
feat(pk): 增加多智能体竞技场, 用于模型能力、思考强度、智能体评估
…-pk-arena Revert "feat(pk): 增加多智能体竞技场"
…t-search # Conflicts: # src-tauri/src/db/service/mod.rs # src/components/conversations/conversation-detail-panel.tsx # src/components/message/message-list-view.tsx
背景
之前左侧的 Ctrl+K 搜索只匹配会话标题。本次改动把用户和助手的聊天文本纳入
搜索范围,在保持低存储占用和强性能的前提下,支持正文搜索、命中定位和结果
高亮。
主要功能
同一会话有多个命中时,提供“下一条匹配 1 / N”逐个跳转。
界面示意
搜索框(可限定文件夹范围):
搜索结果(正文命中带上下文摘要):
实现方案
参与索引,单个文本块最多保留 8192 字节。
逻辑;codeg-mcp 不变。
漂移核对、删除会话时同步清理;内容没有变化时跳过重新计算。
增加额外存储;全文模式使用 FTS5 trigram 索引加短词表,可索引文本超过
40MB 时自动切换,回落到一半以下时切回扫描模式。
updated_at DESC, id DESC),多词查询取交集;每个会话保存块级偏移,命中时按后端字符偏移精确定位到具体消息。
自动重建。
评审后修复
detail=column,修复 1-2 字符查询报错;schema v3 后台重建。后台 worker 异步执行。
N+1 漂移扫描、孤儿文档回收和查询长度上限。
多词 / 流式重复文本 / 请求乱序;保存失败会提示。
测试
查询路径、文件夹过滤、排序确定性、增量索引、空会话墓碑、模式切换、
孤儿回收与关闭开关后的清理。
编译模式的 check、clippy 和全量测试(2460 项)全部通过。
备注
变体在全文模式由 trigram 折叠后于 Rust 侧校验。
取舍,用于控制索引体积和搜索性能。